Merge pull request #368 from CloCkWeRX/add_mqtt_support

Add simple MQTT support to subscribe to, publish messages.

Andrew Cantino 10 years ago
parent
commit
d66015c403
5 changed files with 331 additions and 0 deletions
  1. 2 0
      Gemfile
  2. 2 0
      Gemfile.lock
  3. 138 0
      app/models/agents/mqtt_agent.rb
  4. 52 0
      spec/models/agents/mqtt_agent_spec.rb
  5. 137 0
      spec/support/fake_mqtt_server.rb

+ 2 - 0
Gemfile

@@ -74,6 +74,8 @@ gem 'slack-notifier', '~> 0.5.0'
74 74
 
75 75
 gem 'therubyracer', '~> 0.12.1'
76 76
 
77
+gem 'mqtt'
78
+
77 79
 group :development do
78 80
   gem 'binding_of_caller'
79 81
   gem 'better_errors'

+ 2 - 0
Gemfile.lock

@@ -160,6 +160,7 @@ GEM
160 160
     mime-types (1.25.1)
161 161
     mini_portile (0.5.3)
162 162
     minitest (5.3.3)
163
+    mqtt (0.2.0)
163 164
     multi_json (1.9.3)
164 165
     multi_xml (0.5.5)
165 166
     multipart-post (2.0.0)
@@ -341,6 +342,7 @@ DEPENDENCIES
341 342
   kaminari (~> 0.15.1)
342 343
   kramdown (~> 1.3.3)
343 344
   liquid (~> 2.6.1)
345
+  mqtt
344 346
   mysql2 (~> 0.3.15)
345 347
   nokogiri (~> 1.6.1)
346 348
   protected_attributes (~> 1.0.7)

+ 138 - 0
app/models/agents/mqtt_agent.rb

@@ -0,0 +1,138 @@
1
+# encoding: utf-8 
2
+require "mqtt"
3
+require "json"
4
+
5
+module Agents
6
+  class MqttAgent < Agent
7
+    description <<-MD
8
+      The MQTT agent allows both publication and subscription to an MQTT topic.
9
+
10
+      MQTT is a generic transport protocol for machine to machine communication.
11
+
12
+      You can do things like:
13
+
14
+       * Publish to [RabbitMQ](http://www.rabbitmq.com/mqtt.html)
15
+       * Run [OwnTracks, a location tracking tool](http://owntracks.org/) for iOS and Android
16
+       * Subscribe to your home automation setup like [Ninjablocks](http://forums.ninjablocks.com/index.php?p=/discussion/661/today-i-learned-about-mqtt/p1) or [TheThingSystem](http://thethingsystem.com/dev/supported-things.html)
17
+
18
+      Simply choose a topic (think email subject line) to publish/listen to, and configure your service.
19
+
20
+      It's easy to setup your own [broker](http://jpmens.net/2013/09/01/installing-mosquitto-on-a-raspberry-pi/) or connect to a [cloud service](www.cloudmqtt.com)
21
+
22
+      Hints:
23
+      Many services run mqtts (mqtt over SSL) often with a custom certificate.
24
+
25
+      You'll want to download their cert and install it locally, specifying the ```certificate_path``` configuration.
26
+
27
+
28
+      Example configuration:
29
+
30
+      <pre><code>{
31
+        'uri' => 'mqtts://user:pass@locahost:8883'
32
+        'ssl' => :TLSv1,
33
+        'ca_file' => './ca.pem',
34
+        'cert_file' => './client.crt',
35
+        'key_file' => './client.key',
36
+        'topic' => 'huginn'
37
+      }
38
+      </code></pre>
39
+
40
+      Subscribe to CloCkWeRX's TheThingSystem instance (thethingsystem.com), where
41
+      temperature and other events are being published.
42
+
43
+      <pre><code>{
44
+        'uri' => 'mqtt://kcqlmkgx:sVNoccqwvXxE@m10.cloudmqtt.com:13858',
45
+        'topic' => 'the_thing_system/demo'
46
+      }
47
+      </code></pre>
48
+
49
+      Subscribe to all topics
50
+      <pre><code>{
51
+        'uri' => 'mqtt://kcqlmkgx:sVNoccqwvXxE@m10.cloudmqtt.com:13858',
52
+        'topic' => '/#'
53
+      }
54
+      </code></pre>
55
+
56
+      Find out more detail on [subscription wildcards](http://www.eclipse.org/paho/files/mqttdoc/Cclient/wildcard.html)
57
+    MD
58
+
59
+    event_description <<-MD
60
+      Events are simply nested MQTT payloads. For example, an MQTT payload for Owntracks
61
+
62
+      <pre><code>{
63
+        "topic": "owntracks/kcqlmkgx/Dan",
64
+        "message": {"_type": "location", "lat": "-34.8493644", "lon": "138.5218119", "tst": "1401771049", "acc": "50.0", "batt": "31", "desc": "Home", "event": "enter"},
65
+        "time": 1401771051
66
+      }</code></pre>
67
+    MD
68
+
69
+    def validate_options
70
+      unless options['uri'].present? &&
71
+        options['topic'].present?
72
+        errors.add(:base, "topic and uri are required")
73
+      end
74
+    end
75
+
76
+    def working?
77
+      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
78
+    end
79
+
80
+    def default_options
81
+      {
82
+        'uri' => 'mqtts://user:pass@locahost:8883',
83
+        'ssl' => :TLSv1,
84
+        'ca_file'  => './ca.pem',
85
+        'cert_file' => './client.crt',
86
+        'key_file' => './client.key',
87
+        'topic' => 'huginn',
88
+        'max_read_time' => '10'
89
+      }
90
+    end
91
+
92
+    def mqtt_client
93
+      @client ||= MQTT::Client.new(options['uri'])
94
+
95
+      if options['ssl']
96
+        @client.ssl = options['ssl'].to_sym
97
+        @client.ca_file = options['ca_file']
98
+        @client.cert_file = options['cert_file']
99
+        @client.key_file = options['key_file']
100
+      end
101
+
102
+      @client
103
+    end
104
+
105
+    def receive(incoming_events)
106
+      mqtt_client.connect do |c|
107
+        incoming_events.each do |event|
108
+          c.publish(options['topic'], payload)
109
+        end
110
+
111
+        c.disconnect
112
+      end
113
+    end
114
+
115
+
116
+    def check
117
+      mqtt_client.connect do |c|
118
+
119
+        Timeout::timeout((options['max_read_time'].presence || 15).to_i) {
120
+          c.get(options['topic']) do |topic, message|
121
+
122
+            # A lot of services generate JSON. Try that first
123
+            payload = JSON.parse(message) rescue message
124
+
125
+            create_event :payload => { 
126
+              'topic' => topic, 
127
+              'message' => payload, 
128
+              'time' => Time.now.to_i 
129
+            }
130
+          end
131
+        } rescue TimeoutError
132
+
133
+        c.disconnect   
134
+      end
135
+    end
136
+
137
+  end
138
+end

+ 52 - 0
spec/models/agents/mqtt_agent_spec.rb

@@ -0,0 +1,52 @@
1
+require 'spec_helper'
2
+require 'mqtt'
3
+require './spec/support/fake_mqtt_server'
4
+
5
+describe Agents::MqttAgent do
6
+
7
+  before :each do
8
+    @error_log = StringIO.new
9
+
10
+    @server = MQTT::FakeServer.new(41234, '127.0.0.1')
11
+    @server.just_one = true
12
+    @server.logger = Logger.new(@error_log)
13
+    @server.logger.level = Logger::DEBUG
14
+    @server.start
15
+
16
+    @valid_params = {
17
+      'uri' => "mqtt://#{@server.address}:#{@server.port}",
18
+      'topic' => '/#',
19
+      'max_read_time' => '1',
20
+      'expected_update_period_in_days' => "2"
21
+    }
22
+
23
+    @checker = Agents::MqttAgent.new(
24
+      :name => "somename", 
25
+      :options => @valid_params, 
26
+      :schedule => "midnight",
27
+    )
28
+    @checker.user = users(:jane)
29
+    @checker.save!
30
+  end
31
+
32
+  after :each do
33
+    @server.stop
34
+  end
35
+
36
+  describe "#check" do
37
+    it "should check that initial run creates an event" do
38
+      expect { @checker.check }.to change { Event.count }.by(2)
39
+    end
40
+  end
41
+
42
+  describe "#working?" do
43
+    it "checks if its generating events as scheduled" do
44
+      @checker.should_not be_working
45
+      @checker.check
46
+      @checker.reload.should be_working
47
+      three_days_from_now = 3.days.from_now
48
+      stub(Time).now { three_days_from_now }
49
+      @checker.should_not be_working
50
+    end
51
+  end
52
+end

+ 137 - 0
spec/support/fake_mqtt_server.rb

@@ -0,0 +1,137 @@
1
+#!/usr/bin/env ruby
2
+#
3
+# This is a 'fake' MQTT server to help with testing client implementations
4
+#
5
+# See https://github.com/njh/ruby-mqtt/blob/master/spec/fake_server.rb
6
+#
7
+# It behaves in the following ways:
8
+#   * Responses to CONNECT with a successful CONACK
9
+#   * Responses to PUBLISH by echoing the packet back
10
+#   * Responses to SUBSCRIBE with SUBACK and a PUBLISH to the topic
11
+#   * Responses to PINGREQ with PINGRESP
12
+#   * Responses to DISCONNECT by closing the socket
13
+#
14
+# It has the following restrictions
15
+#   * Doesn't deal with timeouts
16
+#   * Only handles a single connection at a time
17
+#
18
+
19
+$:.unshift File.dirname(__FILE__)+'/../lib'
20
+
21
+require 'logger'
22
+require 'socket'
23
+require 'mqtt'
24
+
25
+
26
+class MQTT::FakeServer
27
+  attr_reader :address, :port
28
+  attr_reader :last_publish
29
+  attr_reader :thread
30
+  attr_reader :pings_received
31
+  attr_accessor :just_one
32
+  attr_accessor :logger
33
+
34
+  # Create a new fake MQTT server
35
+  #
36
+  # If no port is given, bind to a random port number
37
+  # If no bind address is given, bind to localhost
38
+  def initialize(port=nil, bind_address='127.0.0.1')
39
+    @port = port
40
+    @address = bind_address
41
+  end
42
+
43
+  # Get the logger used by the server
44
+  def logger
45
+    @logger ||= Logger.new(STDOUT)
46
+  end
47
+
48
+  # Start the thread and open the socket that will process client connections
49
+  def start
50
+    @socket ||= TCPServer.new(@address, @port)
51
+    @address = @socket.addr[3]
52
+    @port = @socket.addr[1]
53
+    @thread ||= Thread.new do
54
+      logger.info "Started a fake MQTT server on #{@address}:#{@port}"
55
+      loop do
56
+        # Wait for a client to connect
57
+        client = @socket.accept
58
+        @pings_received = 0
59
+        handle_client(client)
60
+        break if just_one
61
+      end
62
+    end
63
+  end
64
+
65
+  # Stop the thread and close the socket
66
+  def stop
67
+    logger.info "Stopping fake MQTT server"
68
+    @socket.close unless @socket.nil?
69
+    @socket = nil
70
+
71
+    @thread.kill if @thread and @thread.alive?
72
+    @thread = nil
73
+  end
74
+
75
+  # Start the server thread and wait for it to finish (possibly never)
76
+  def run
77
+    start
78
+    begin
79
+      @thread.join
80
+    rescue Interrupt
81
+      stop
82
+    end
83
+  end
84
+
85
+
86
+  protected
87
+
88
+  # Given a client socket, process MQTT packets from the client
89
+  def handle_client(client)
90
+    loop do
91
+      packet = MQTT::Packet.read(client)
92
+      logger.debug packet.inspect
93
+
94
+      case packet
95
+        when MQTT::Packet::Connect
96
+          client.write MQTT::Packet::Connack.new(:return_code => 0)
97
+        when MQTT::Packet::Publish
98
+          client.write packet
99
+          @last_publish = packet
100
+        when MQTT::Packet::Subscribe
101
+          client.write MQTT::Packet::Suback.new(
102
+            :message_id => packet.message_id,
103
+            :granted_qos => 0
104
+          )
105
+          topic = packet.topics[0][0]
106
+          client.write MQTT::Packet::Publish.new(
107
+            :topic => topic,
108
+            :payload => "hello #{topic}",
109
+            :retain => true
110
+          )
111
+          client.write MQTT::Packet::Publish.new(
112
+            :topic => topic,
113
+            :payload => "did you know about #{topic}",
114
+            :retain => true
115
+          )
116
+
117
+        when MQTT::Packet::Pingreq
118
+          client.write MQTT::Packet::Pingresp.new
119
+          @pings_received += 1
120
+        when MQTT::Packet::Disconnect
121
+          client.close
122
+        break
123
+      end
124
+    end
125
+
126
+    rescue MQTT::ProtocolException => e
127
+      logger.warn "Protocol error, closing connection: #{e}"
128
+      client.close
129
+  end
130
+
131
+end
132
+
133
+if __FILE__ == $0
134
+  server = MQTT::FakeServer.new(MQTT::DEFAULT_PORT)
135
+  server.logger.level = Logger::DEBUG
136
+  server.run
137
+end